前言
HashMap是Java中常用的数据结构,是集合类中的重要存在,其中包含了散列表、链表和红黑树。散列表解决冲突的方法是链地址法,即将散列值相同的元素存放在一个链表中。当冲突过多,链表过长会造成查找效率降低,因此Java8中在HashMap中引入红黑树进行优化,当某个散列值下的链表长度过长(长度大于8),会将其转变为红黑树存储,优化查询。涉及到红黑树的操作不会再这里细讲
源码解读
构造函数
1 | // 默认初始化容量 |
- initialCapacity: 初始容量
- loadFactor:负载因子
- threshold:容量阈值,当HashMap的存储的元素达到该值时,就会触发扩容操作
capatcity在源码中没有定义变量记录是因为其大小为table数组的长度,阈值threshold的大小由capatcity和loadFactor来决定,threshold=capatcity*loadFactor
HashMap的构造函数主要是设置初始化容量和负载因子,如果采用无参数构造函数,则使用默认的初始化容量16和负载因子0.75。当采用设置了初始化容量的构造函数时,就会调用tableSizeFor方法,该方法通过位运算的方法得到一个2的n次方,该值刚好大于cap,即当用户指定一个初始化容量为2的n次方的值,实际HashMap的阈值赋值为大于该值的一个2的n次方的值,在初始化的时候,其首先按照threshold的大小开辟数组,接着经过一次赋值threshold=threshold*loadFactor
(后文resize方法中可以看到)改变threshold的大小,符合上述的说法。
为什么一定要取一个2的n次方容量的数组,这是因为2的n可以方便取余,如果size为2的n次方,则求除以size的余数通过&(size)即可得到,其次&(size)取hash散列的结果分布只会受到原本对象的hash值影响,不会受到散列函数影响
基本存储单元
HashMap的散列表是一个数组table,其中的元素是Node类,该类继承了Map.Entry接口,其中包含了key,value,hashCode和next(存储链表中下一跳)。在java8之前是内置的Entry类,而在java8中Entry类变成了Node类。
1 | static class Node<K,V> implements Map.Entry<K,V> { |
红黑树中节点TreeNode
1 | static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> { |
可以看到TreeNode继承LinkedHashMap.Entry,而LinkedHashMap.Entry是继承HashMap.Node,所以TheeNode是HashMap.Node的子类,对于HashMap的元素普遍操作(如遍历)都可以转为对Node的操作,无需判断table中存储从是链表还是红黑树。
查找
1 | public V get(Object key) { |
(n-1)& hash是用位运算计算hash%(n-1),确定key在哈希表中的位置。其hash值的运算不是直接采用HashCode,而是通过HashCode重新计算,因为当table表长度小,其散列值的产生只有低位有关,与高位无关,若加入的元素产生的hashCode低位比较相似,高位不同,则会造成大量冲突。因此将高16位与低16位做异或运算,使hash值低位受HashCode影响,使散列值分布均匀。
遍历
HashMap的遍历经常是通过遍历keySet和entrySet方法产生的Set来实现的。
1 | public Set<K> keySet() { |
可以看到keySet的迭代器继承HashIterator,其中KeyIterator的next方法直接调用了HashIterator的nextNode方法,nextNode是通过遍历table找到不为null的元素,接着再调用Node的next,不断从链表中遍历元素,但是其中冲突可能会用红黑树存储,对于红黑树的遍历并没有描写。这是因为上文讲到红黑树的TreeNode继承了Node,TreeNode的next存储着红黑树遍历下一跳的元素,所以只要调用Node类的next就可以遍历链表和红黑树,下文会看到ThreeNode的next的建立。
插入
除去红黑树的操作,插入是HashMap较为复杂的方法,因为其设计到table的扩容,链表转为红黑树等问题,先来看代码。
1 | static final int TREEIFY_THRESHOLD = 8; |
可以看到table是在插入的时候进行判断有无进行初始化并对其初始化,初始化和扩容都是调用resize方法,整个插入过程是首先检查是否初始化,无则进行初始化,接着根据插入元素的hash的散列值找到数组中的位置,如果不存在冲突则直接存入,如果是链表,则遍历链表,如果是红黑树,则遍历树,对其已存在的key,替换value,不存在则插入到链表尾部或者红黑树中。其中在插入到链表只若容量大于8,则需要将链表转为红黑树。
1 | static final int MIN_TREEIFY_CAPACITY = 64; |
转为红黑树过程中,建立了next和prev的指向,即建立好的红黑树后,结点之间通过next和prev可以遍历整颗树,这也是遍历过程值中不需要考虑红黑树的原因,如何建立红黑树,这里不细讲,下次分析红黑树中再讲。
接着来看resiz方法怎么来初始化和扩展table。
1 | final Node<K,V>[] resize() { |
当table为空时,需要初始化table,将如果指定了初始化容量,则其threshold做初始化长度,接着将threshold赋值为threshold*loadFactor,否则直接按照默认table长度为16,负载因子为0.75,阈值为16*0.75。如果已经初始化,则要对其进行扩容,将容量扩充为原来的两倍,扩充之后需要对散列表中的元素重新分配,在重新分配在java7是直接遍历元素进行重新计算散列值,但是在java8中进行了优化,将需要分配的链表或者红黑树分成两个链表,分别放入各自在新的表位置中。可以看到在代码中loHead和loTail、hiHead和hiTail分别是两个链表的头结点和尾结点,这里称为lo链表和hi链表,旧散列表中同一个链表中的元素按照hash值和旧表容量进行与运算,判断是否为0分为两类,放入两个链表中,最后两个链表分别放入新表的位置中。
其中的原理可以通过一个例子说明,假设一个HashMap,table容量为8,在余数为2的链上有key的hash值为2、10、18、26的元素。一开始元素确定在table中的位置是通过与n-1(例子中为7)取余的方式。
扩展容量后为16,则其确定在散列表的中的位置同样与容量与运算(n-1变为15),如果重新与运算的话
仔细观察的话,其实后三位的取与结果是一样的,不同的只有第四位上计算不同,而这取决于hash在第四位是0还是1,因此其实只要得到hash值的第四位是0还是1,就可以决定放入表中的位置。如果是0的话还是旧的位置,如果是1的话,则新位置为旧的位置加上8(2的3次方)。而确定第五位的值,只需要将hash值与旧容量值(8:0000 1000)取与运算就能得到
因此只需与oldCap取与运算,判断是否为0分两个链表,0的链表维持原来的位置,1的则加上oldCap的值作为新位置
对于红黑树的分裂放入方法也差不多
1 | static final int UNTREEIFY_THRESHOLD = 6;s |
删除
删除操作比较简单,相当于在查找的基础上删掉该元素。
1 | public V remove(Object key) { |
在jdk8和jdk7中的区别
- jdk8中引入了红黑树,将链表长度超过8的转变为红黑树,jdk7中则只使用链表
- jdk8中元素插入链表在链表的尾部,而jdk7中是插入到头部,jdk8将其插入到尾部并加入红黑树,解决了jdk7的HashMap在多并发下扩容时会陷入链表成环的问题
- Jdk8在扩容重新分配时使用了新的方法,与就容量取与运算结果分为两个链表或树,而jdk7是重新计算hash取余的结果
非默认序列化
HashMap 并没有使用默认的序列化机制,table变量被transient修饰,无法序列化,而是通过实现readObject/writeObject
两个方法自定义了序列化的内容,因为序列化 talbe 存在着两个问题:
table 多数情况下是无法被存满的,序列化未使用的部分,浪费空间
同一个键值对在不同 JVM 下,所处的桶位置可能是不同的,在不同的 JVM 下反序列化 table 可能会发生错误。
在不同的jvm下会有不同的HasCode,直接序列化生产对于后续加入元素会产生错误。